Skip to content

feat(runtime): canary wrapper lifetime mode - #561

Draft
edusperoni wants to merge 3 commits into
feat/wrapper-lifetime-rootedfrom
feat/wrapper-lifetime-canary
Draft

edusperoni wants to merge 3 commits into
feat/wrapper-lifetime-rootedfrom
feat/wrapper-lifetime-canary

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Third of the stacked wrapper-lifetime series, on top of #559. Adds "mode": "canary", the portable resurrection-free model. It is the same shape the Node-API runtimes use (a host object plus an expando table) and the one Android can implement. The default stays resurrecting.

V8: canary relies on a fix for own-property lookups through a kNonMasking interceptor whose prototype has a named interceptor (crbug 585053531). It shipped as v8_nonmasking_own_lookup.patch in v8-buildscripts v8-14.9.207.39-10 (NativeScript/v8-buildscripts#11), and this PR pins that release. Main is on -7, so the pin also brings -8 (Apple TV variants) and -9 (Mac Catalyst with its JIT tiers). Merge after #533, which carries the runtime side of the Catalyst JIT.

Current behavior

resurrecting and rooted keep a single wrapper whose JS properties live on the wrapper itself. rooted keeps it alive by reading -retainCount, which only iOS can do.

New behavior

With "wrapperLifetime": { "mode": "canary" }:

  • The wrapper is a canary. A wrapper in scope is a phantom-weak JS object that dies with its JS references, and its native object is released through the same queue as rooted. No retain counts are read.
  • State lives on a separate object. kNonMasking named interceptors on the three ObjC instance templates (empty instance, native constructor, JS-extended constructor) forward JS properties to a null-prototype state object: get, set, query, delete, enumerate, define and descriptor. Prototype members (native methods and properties, JS-extended class methods) are found first and never reach the interceptors, so native calls cost the same.
  • The state outlives the canary. It is rooted from its first write until the native object is deallocated, which an associated token's -dealloc reports. A canary built later for the same object forwards to the same state.
  • Scope: "extended" (default) makes canaries of JS-extended instances only. "all" covers every ObjC wrapper except blocks and tagged pointers, and is experimental: attaching the token aborts for any class the ObjC runtime marks as forbidding associated objects, and there is no public query for that.

The contract.

  • Self-references leak. The state cannot be rooted only once the canary dies, because a state reachable only through the canary is garbage in the same collection. So state that references its own object (a closure over this, a WeakMap value naming it) keeps that object alive. The iOS-only alternative, unrooting at major-GC start when nothing native owns the object (as rooted does), is a marked switch point in WrapperLifetimeCanary.h and is not implemented.
  • Identity changes across a canary's death. === with the old wrapper fails, and WeakMap keys and WeakRefs of the old canary do not carry over. A follow-up (PR3b) replaces the weak collections in canary mode so they key native objects by their state. Native #private fields are brand-checked on the canary and do not carry over either.
  • Interceptor limits.
    • Object.freeze, seal and preventExtensions throw on a canary.
    • Index-keyed properties are not forwarded.
    • A write through an object whose prototype is a canary lands in the canary's state, because interceptors see the holder, not the receiver.
    • An accessor defined on a canary runs with the canary as this.

Robustness.

  • Records are shared between the registry and the canary and carry a died flag. objc_removeAssociatedObjects on a live object therefore leaves its canary with no state, rather than a dangling record; the next write starts a new one.
  • Teardown empties every state handle while the isolate is alive. Later token deallocs, on any thread, find the registry closed.

getWrapperLifetimeStats() gains canaryStates and canaryNativeDeaths. Docs: docs/ns-builtin-modules.md, docs/knowledge/v8-resurrecting-finalizers.md, types/ns-runtime.d.ts.

Testing

  • WrapperLifetimeModesTests.js gains canary expectations:

    • own-property semantics (keys, in, hasOwn, descriptors, delete, non-enumerable and accessor properties);
    • state handed back on a new canary;
    • no forwarding outside the extended scope;
    • state let go at native death;
    • the self-reference leak;
    • removed associations;
    • freeze.

    The existing rooted expectations also run under canary where the contract matches.

  • On the v8-14.9.207.39-10 prebuilts: full suite, 0 failures (1897 specs), in each of canary/all, canary/extended, resurrecting/extended and rooted/all. ASan, canary/all, on the CI artifacts of v8-buildscripts#11 (same patch, before the release was cut): 0 failures, no reports. Each run's log was checked for the mode it exercised: an earlier round had set the config under the wrong key and silently ran the default mode.

  • The real canary/all runs found a runtime-made self-reference: SymbolIterator::Set gave every NSArray wrapper a Symbol.iterator function bound to that wrapper. Under canary that property lives on the state, so arrays were never released. The function now iterates its receiver (second commit). Every NSArray returned from native code under "all" still gets a state object for that property; moving the iterator to the NSArray prototype would avoid it, and is left as a follow-up.

  • Release, simulator, patched V8, 5 alternating runs per mode (each run's mode checked from inside the app), medians in ns:

Case resurrecting canary/extended canary/all
Prototype hit: JS method / native method / native property 26 / 123 / 92 26 / 124 / 90 27 / 123 / 92
Extended instance: expando read / write 18 / 17 64 / 64 64 / 66
Extended instance: property miss 20 48 48
Plain object: expando read 18 19 64
Plain object: property miss 20 34 49
Create, set one expando, drop (Ext.new().tag = 1) 690 1273 1538
Full GC disposing JS-only wrappers, per wrapper 2665 2403 4176
NSArray.arrayWithArray([{a: 1}]) 4369 4078 4834

Prototype members cost the same. Expandos and misses go through the interceptors. The handlers are installed per template, so under "extended" a miss on a plain object also pays their decline (+14 ns). Under "all", every NSArray returned from native code gets a state object for the runtime's own Symbol.iterator, which shows in the array-return and GC rows. Moving that iterator to the NSArray prototype is the obvious follow-up.

@coderabbitai

coderabbitai Bot commented Nov 12, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Adds "wrapperLifetime": { "mode": "canary" }, the portable resurrection-
free model: the one the Node-API runtimes and Android can implement too.

- A wrapper in scope is a canary: a phantom-weak JS object that dies
  with its JS references and releases the runtime's retain through the
  same queue as "rooted". No retain counts are read.
- kNonMasking named interceptors on the three ObjC instance templates
  forward JS properties to a null-prototype state object. Prototype
  members (native methods and properties, JS-extended class methods)
  are found before the interceptors and never reach them.
- The state object is rooted from its first write until the native
  object is deallocated, reported by an associated token's -dealloc. A
  canary built later for the same object forwards to the same state.

The state cannot be rooted only once the canary dies: a state reachable
only through the canary is garbage in the same collection. So state
that references its own object (a closure over `this`) keeps it alive;
the iOS-only alternative, unrooting at major-GC start when nothing
native owns the object, is left as a marked switch point in
WrapperLifetimeCanary.h.

Records are shared between the registry and the canary and carry a
died flag, so objc_removeAssociatedObjects on a live object leaves its
canary with no state instead of a dangling record. Teardown empties
every state handle while the isolate is alive and later token deallocs
find the registry closed.

Requires V8's own-property lookups through kNonMasking interceptors to
skip a prototype's named interceptor (crbug 585053531), which the V8
prebuilts get from v8_nonmasking_own_lookup.patch.
SymbolIterator::Set gave each NSArray wrapper its own Symbol.iterator
function bound to the wrapper. Under the canary wrapper lifetime mode
with "scope": "all" that property lives on the array's state object, so
the state referenced its own canary and the array was never released.
The function now iterates its receiver, which is what `for...of` and
`arr[Symbol.iterator]()` pass anyway.
@edusperoni
edusperoni force-pushed the feat/wrapper-lifetime-canary branch from c936676 to 683780a Compare November 12, 2026 22:56
The canary wrapper lifetime mode needs v8_nonmasking_own_lookup.patch
(crbug 585053531), first shipped in -10. Moving from -7 also brings
-8 (Apple TV variants) and -9 (Mac Catalyst with its JIT tiers, whose
runtime support is #533).
@edusperoni
edusperoni added this pull request to stack #562 November 13, 2026 00:11

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant